LLM - Lame Language Model¶

Os modelos generativos de linguagem atuais, baseados em redes neurais, funcionam, de forma simplificada, baseados em dois pontos:

  • Predição da próxima palavra: envolve a previsão da próxima palavra em uma sequência de texto, com base nas palavras anteriores. Essa abordagem é fundamental para o funcionamento de modelos como o GPT (Generative Pre-trained Transformer), que aprende padrões linguísticos a partir de grandes quantidades de dados textuais.

  • Geração de texto autoregressivo: após a previsão da próxima palavra, o modelo gera texto de forma autoregressiva, o que significa que ele utiliza a palavra prevista como parte da entrada para a próxima predição. Este processo se repete, permitindo a construção de longas sequências de texto, onde cada palavra é influenciada pelas anteriores. Essa abordagem permite que os modelos criem respostas ou narrativas que são contextualmente relevantes e fluídas.

drawing

Apesar de serem ferramentas úteis, os melhores modelos contém bilhões de parâmetros, sendo extremamente custosos computacionalmente, além de serem caixas pretas, ou seja, só podem ser "entendidos" em termo das entradas e saídas, mas pouco se compreende em relação ao funcionamento interno (por que determinada entrada gera tal saída? como?).

Sabendo disso, vamos construir uma versão extremamente "simples" desses modelos, baseada em uma distribuição categórica. Vamos chamar esse modelo de "Lame Language Model" (um modelo "pouco convincente" de linguagem).

Distribuição categórica¶

A distribuição categórica é uma distribuição de probabilidade discreta que descreve a probabilidade de ocorrência de cada uma de várias categorias distintas em um único experimento. Ela é uma generalização da distribuição de Bernoulli, que se aplica apenas a duas categorias (sucesso ou fracasso). A distribuição categórica pode lidar com qualquer número de categorias.

Dessa maneira, a Função Massa de Probabilidade (FMP) de uma distribuição categórica é representada por:

${\displaystyle f(x=i\mid {\boldsymbol {p}})=p_{i},}$

onde

${\displaystyle {\boldsymbol {p}}=(p_{1},\ldots ,p_{k})}$,

${\displaystyle p_{i}}$ representa a probabilidade de ver o elemento i

e ${\displaystyle \textstyle {\sum _{i=1}^{k}p_{i}=1}}$

Distribuição categórica para gerar texto¶

A distribuição categórica pode ser usada como ferramenta para modelar a frequência com que palavras ou combinações de palavras ocorrem. Ao considerar cada palavra como uma categoria, é possível gerar amostras de texto baseando-se apenas na probabilidade de cada palavra (com base em sua frequência global). Obviamente, essa estratégia não leva em conta o contexto das palavras, ou seja, o que aparece antes e após. Com isso, esse modelo geraria texto a partir de uma distribuição fixa de palavras, onde é mais provável que palavras de alta frequência apareçam.

Vamos testar abaixo essa ideia...

Distribuição categórica¶

In [1]:
# Bibliotecas

from collections import Counter
import os
import re

import nltk
from nltk.corpus import stopwords
from nltk.tokenize import word_tokenize
import string
import plotly.graph_objects as go
from IPython.display import clear_output
import numpy as np
from scipy.stats import rv_discrete
import random
from plotly.subplots import make_subplots


# Download required NLTK data files (if not already installed)
nltk.download('punkt')
nltk.download('punkt_tab')
nltk.download('stopwords')
[nltk_data] Downloading package punkt to /home/joaorobson/nltk_data...
[nltk_data]   Package punkt is already up-to-date!
[nltk_data] Downloading package punkt_tab to
[nltk_data]     /home/joaorobson/nltk_data...
[nltk_data]   Package punkt_tab is already up-to-date!
[nltk_data] Downloading package stopwords to
[nltk_data]     /home/joaorobson/nltk_data...
[nltk_data]   Package stopwords is already up-to-date!
Out[1]:
True

Dados¶

Os dados utilizados são das seguintes fontes:

  • GPT-4 Technical Report
  • Wikipedia Movie Plots
  • Dune
  • Meditations
  • Crime and Punishment
  • The Invisible Man
In [2]:
# Leitura dos dados

data = {}
path = "data/"
titles = []

for filename in os.listdir(path):
    print(filename)
    file = open(os.path.join(path, filename), "r")
    title = re.sub("([()]|\.txt)","", filename)
    data[title] = file.read()
    titles.append(title)
    file.close()
GPT-4_Technical_Report.txt
The_Invisible_Man_(H._G._Wells).txt
Wikipedia_Movie_Plots.txt
Dune_(Frank_Herbert).txt
Meditations_(Marcus_Aurelius).txt
Crime_and_Punishment_(Fyodor_Dostoyevsky).txt
In [3]:
data["Meditations_Marcus_Aurelius"][1400:1700]
Out[3]:
"rus, had held high office in Rome, and his grandfather, of\nthe same name, had been thrice Consul. Both his parents died young, but\nMarcus held them in loving remembrance. On his father's death Marcus\nwas adopted by his grandfather, the consular Annius Verus, and there was\ndeep love between these two"
In [4]:
# Pré-processamento do texto (lowercase e retirar pontuação)

def clean_text(text):
    # Lowercase
    text = text.lower()
    
    # Remover pontuação
    #text = text.translate(str.maketrans('', '', string.punctuation))
    text = re.sub('[^\s\w_]+', '', text)

    text = re.sub(r'\d+', '', text)        
    
    # Tokenize
    words = word_tokenize(text)
    
    # Remover stopwords (palavras frequentes (of, the, etc.)
    #stop_words = set(stopwords.words('english'))
    #words = [word for word in words if word not in stop_words]
    
    return words
In [5]:
" ".join(clean_text(data["Meditations_Marcus_Aurelius"][1400:1700]))
Out[5]:
'rus had held high office in rome and his grandfather of the same name had been thrice consul both his parents died young but marcus held them in loving remembrance on his fathers death marcus was adopted by his grandfather the consular annius verus and there was deep love between these two'

Calculando frequência de cada palavra¶

In [6]:
tokens = clean_text(' '.join(data.values()))
total_num_tokens = len(tokens)
freq = Counter(tokens)
In [7]:
print('Tamanho do vocabulário:', len(freq))
Tamanho do vocabulário: 101346
In [8]:
freq['muaddib']
Out[8]:
127

Calculando distribuição (probabilidade de cada palavra)¶

In [9]:
probabilities = {}
for token in freq:
    probabilities[token] = freq[token]/total_num_tokens
In [10]:
probabilities['muaddib']
Out[10]:
2.975352368403913e-05
In [11]:
print(f'Soma das probabilidades: {sum(probabilities.values()):.2f}')
Soma das probabilidades: 1.00
In [12]:
words, frequencies = zip(*freq.most_common(30))  # 30 palavras mais frequentes

fig = go.Figure(data=[go.Bar(x=words, y=frequencies)])
fig.update_layout(
    title="Distribuição das palavras",
    xaxis_title="Palavras",
    yaxis_title="Frequência",
    template="plotly_white"
)

Simulação de Monte Carlo¶

In [13]:
categories = list(probabilities.keys())

# Criando uma distribuição discreta (categórica) das probabilidades calculadas
custom_dist = rv_discrete(name='custom', values=(np.arange(len(categories)), list(probabilities.values())))

# Realizando amostragem
num_samples = 10000
samples = custom_dist.rvs(size=num_samples)
samples = [categories[i] for i in samples]
print("\nEmplos de amostras geradas:")
print(samples[:40])
Emplos de amostras geradas:
['old', 'is', 'to', 'after', 'mugaambigaambaal', 'nuclear', 'team', 'the', 'him', 'who', 'gaggaiah', 'laurie', 'of', 'jeffrey', 'runs', 'know', 'of', 'she', 'selfish', 'a', 'wants', 'has', 'change', 'ricky', 'make', 'in', 'background', 'father', 'usurp', 'might', 'explains', 'as', 'at', 'to', 'first', 'and', 'done', 'her', 'and', 'a']
In [14]:
freq_monte_carlo = Counter(samples)

words_mc, frequencies_mc = zip(*freq_monte_carlo.most_common(30))  # 30 palavras mais frequentes

fig = go.Figure(data=[go.Bar(x=words_mc, y=frequencies_mc)])
fig.update_layout(
    title="Distribuição das palavras - Simulação de Monte Carlo",
    xaxis_title="Palavras",
    yaxis_title="Frequência",
    template="plotly_white"
)
In [15]:
fig = make_subplots(rows=2, cols=1,  subplot_titles=("Distribuição das palavras", 
                                                                       "Distribuição das palavras - Simulação de Monte Carlo"))

fig.add_trace(go.Bar(
    x=words,
    y=frequencies,
    name='Distribuição das palavras',
    marker_color='blue'
), row=1, col=1)

fig.add_trace(go.Bar(
    x=words_mc,
    y=frequencies_mc,
    name='Distribuição das palavras - Simulação de Monte Carlo',
    marker_color='orange'
), row=2, col=1)

fig.update_xaxes(title_text="Palavras", row=1, col=1)
fig.update_xaxes(title_text="Palavras", row=2, col=1)
fig.update_yaxes(title_text="Frequência", row=1, col=1)
fig.update_yaxes(title_text="Frequência", row=2, col=1)

fig.update_layout(
    title_text='Comparação - Distribuição vs Simulação de Monte Carlo',
    height=600,
)

Apesar de, em tese, fazer sentido utilizar uma distribuição categórica para texto, as amostras geradas pelo modelo claramente não tem nenhum sentido. Isso deve porque essa distribuição considera que cada palavra ou sequência de palavras é uma categoria independente. Ou seja, ainda que capture a chance de cada palavra ocorrer, esse modelo ignora qualquer dependência condicional entre palavras. Isso quer dizer que ele considera que uma palavra $w_{k}$ é independente das palavras anteriores $w_{k-1},...,w_{0}$, gerando uma amostra de texto baseada apenas em frequências.

Mas é possível melhorar isso. Baseando-se em probabilidades condicionais, é possível modelar probabilidades atreladas a n-gramas (conjuntos de n palavras em sequência). Um modelo assim assume que cada palavra $w_{k}$ depende apenas das últimas $n-1$ palavras ($w_{k−n+1},w_{k−n+2},…,w_{k−1})$.

Dessa forma, com base na fórmula de probabilidade condicional para dois eventos, definida como:

$P(X_{2}|X_{1}) = \Large\frac{P(X_{1},X_{2})}{P(X_{1})}$

É possível estimar a probabilidade de uma palavra $w_{k}$ dado uma palavra $w_{k-1}$ como sendo:

$P(w_{k}|w_{k-1}) = \Large\frac{P(w_{k-1},w_{k})}{P(w_{k-1})}$

Generalizando para qualquer valor de n:

$P(w_{k}|w_{k-n+1},w_{k-n+2},...,w_{k-1}) = \Large\frac{P(w_{k-n+1},w_{k-n+2},...,w_{k})}{P(w_{k-n+1},w_{k-n+2},...,w_{k-1})}$

Simplificando:

$P(w_{k}|w_{k-n+1},w_{k-n+2},...,w_{k-1}) = \Large\frac{count(w_{k-n+1},w_{k-n+2},...,w_{k})}{count(w_{k-n+1},w_{k-n+2},...,w_{k-1})}$

Com isso, pode-se modelar a probabilidade de encontrar uma palavra $w_{n}$ a partir das n palavras anteriores.

Assim, para tornar nosso LLM mais convincente, é possível continuar utilizando a distrituição categórica, mas estimando as probabilidades de cada palavra tomando como base as palavras anteriores. Nessa estratégia, a cada palavra prevista, é possível consultar qual a próxima palavra mais provável, gerando textos mais coerentes.

Vamos fazer isso utilizando trigramas (prever a próxima palavra baseado nas duas últimas).

Modelo de trigramas¶

Em um modelo de trigramas, a probabilidade de uma determinada palavra $w_{k}$ é dada por:

$P(w_{k}|w_{k-1}, w_{k-2}) = \Large\frac{P(w_{k-2},w_{k-1},w_{k})}{P(w_{k-2},w_{k-1})}$

Dessa forma, para desenvolver um modelo baseado em trigramas na prática, é necessário seguir o seguinte passo a passo:

  • Calcular a frequência dos bigramas existentes no dado (possíveis numeradores da equação acima);
  • Calcular a frequência dos trigramas existentes no dado (possíveis denominadores da equação acima);
  • Calcular a distribuição de probabilidades para cada palavra $w_{k}$ do vocabulário.
In [16]:
# Capturando frequências dos bigramas

bigrams = []

for i in range(0, len(tokens) - 1):
    bigrams.append((tokens[i], tokens[i + 1]))

bigrams_freq = Counter(bigrams)
del bigrams
In [17]:
top_30_bigrams, top_30_bigrams_frequencies = zip(*bigrams_freq.most_common(30))  # 30 palavras mais frequentes

top_30_bigrams = [' '.join(bigram) for bigram in top_30_bigrams]

fig = go.Figure(data=[go.Bar(x=top_30_bigrams, y=top_30_bigrams_frequencies)])
fig.update_layout(
    title="Distribuição dos bigramas",
    xaxis_title="Palavras",
    yaxis_title="Frequência",
    template="plotly_white"
)
In [18]:
# Calculando frequência de trigramas

trigrams = []

for i in range(0, len(tokens) - 2):
    trigrams.append((tokens[i], tokens[i + 1], tokens[i+2]))

trigrams_freq = Counter(trigrams)
del trigrams
In [19]:
trigrams_model = {}

for trigram, trigram_freq in trigrams_freq.items():
    if not trigrams_model.get((trigram[0], trigram[1])):
        trigrams_model[(trigram[0], trigram[1])] = {}
    trigrams_model[(trigram[0], trigram[1])][trigram[2]] = trigram_freq/bigrams_freq.get((trigram[0], trigram[1]))
In [20]:
trigrams_model[('the','sun')]
Out[20]:
{'jaffers': 0.0072992700729927005,
 'is': 0.08029197080291971,
 'while': 0.0072992700729927005,
 'shines': 0.029197080291970802,
 'shifts': 0.0072992700729927005,
 'many': 0.0072992700729927005,
 'returns': 0.0072992700729927005,
 'itself': 0.014598540145985401,
 'however': 0.0072992700729927005,
 'rises': 0.13138686131386862,
 'plunging': 0.014598540145985401,
 'but': 0.014598540145985401,
 'comes': 0.014598540145985401,
 'sets': 0.029197080291970802,
 'meanwhile': 0.014598540145985401,
 'a': 0.014598540145985401,
 'in': 0.021897810218978103,
 'rise': 0.051094890510948905,
 'elvis': 0.0072992700729927005,
 'for': 0.014598540145985401,
 'instead': 0.0072992700729927005,
 'under': 0.0072992700729927005,
 'forced': 0.0072992700729927005,
 'drive': 0.0072992700729927005,
 'verifies': 0.0072992700729927005,
 'has': 0.014598540145985401,
 'the': 0.014598540145985401,
 'rising': 0.014598540145985401,
 'and': 0.021897810218978103,
 'miss': 0.0072992700729927005,
 'into': 0.0072992700729927005,
 'too': 0.0072992700729927005,
 'causing': 0.0072992700729927005,
 'during': 0.0072992700729927005,
 'he': 0.0072992700729927005,
 'which': 0.0072992700729927005,
 'going': 0.0072992700729927005,
 'starts': 0.014598540145985401,
 'setting': 0.0072992700729927005,
 'roof': 0.0072992700729927005,
 'breaks': 0.0072992700729927005,
 'goes': 0.014598540145985401,
 'dr': 0.0072992700729927005,
 'probe': 0.0072992700729927005,
 'webb': 0.0072992700729927005,
 'as': 0.0072992700729927005,
 'are': 0.014598540145985401,
 'shining': 0.0072992700729927005,
 'up': 0.0072992700729927005,
 'to': 0.014598540145985401,
 'shine': 0.0072992700729927005,
 'fun': 0.0072992700729927005,
 'according': 0.0072992700729927005,
 'at': 0.0072992700729927005,
 'glare': 0.0072992700729927005,
 'your': 0.0072992700729927005,
 'of': 0.014598540145985401,
 'lifted': 0.014598540145985401,
 'dipped': 0.014598540145985401,
 'whom': 0.0072992700729927005,
 'saluted': 0.0072992700729927005,
 'was': 0.029197080291970802,
 'you': 0.0072992700729927005,
 'seemed': 0.0072992700729927005,
 'historians': 0.0072992700729927005,
 'or': 0.0072992700729927005,
 'take': 0.0072992700729927005,
 'seemeth': 0.0072992700729927005,
 'when': 0.0072992700729927005,
 'though': 0.0072992700729927005,
 'will': 0.0072992700729927005,
 'shone': 0.014598540145985401,
 'why': 0.0072992700729927005}

Agora é possível gerar um texto utilizando as distribuições categóricas construídas. Para cada bigrama $b({w_g}) = (w_{g-2},w_{g-1})$ existente no dado, temos uma distribuição categórica formada pelas probabilidades de $P(w_g|w_{g-1},w_{g-2})$. Ou seja, para cada par de palavras, a próxima palavra pode ser

Assim, o processo de geração da palavra $w_g$ consiste em:

    1. Dado as duas palavras anteriores $(w_{g-2},w_{g-1})$, coletar a distribuição contendo as probabilidades $P(w_g| w_{g-1}, w_{g-2})$;
    1. Realizar 1000 simulações de Monte Carlo, extraindo amostras a partir da distribuição categórica selecionada no passo anterior;
    1. Designar a palavra mais frequente gerada pelo passo anterior como $w_g$;
    1. Repetir passo 1.
In [21]:
prompt = "a cold"
output = prompt

prompt = tuple(prompt.split())
n_words = 10

while n_words > 0:
    categories = list(trigrams_model[prompt].keys())
    
    custom_dist = rv_discrete(name='custom', values=(np.arange(len(categories)), list(trigrams_model[prompt].values())))
    
    # Amostragem da distribuição
    num_samples = 1000
    samples = custom_dist.rvs(size=num_samples)
    samples = [categories[i] for i in samples]
    
    samples_counter = Counter(samples)
    next_word = samples_counter.most_common(1)[0][0]
    output = output + " " + next_word
    n_words -= 1
    prompt = (output.split()[-2], output.split()[-1])
    
print(output)
a cold and distant mimi is not a refusal contains harmful content

Tornando o LLM um modelo n-gram genérico¶

Para possibilitar simulações considerando $n$ palavras anteriores, é possível tornar o código acima genérico, aceitando valores diversos para $n$.

Para isso, basta gerar as frequências para $n-1-grams$ e $n-grams$, utilizando a fórmula geral:

$P(w_{k}|w_{k-n+1},w_{k-n+2},...,w_{k-1}) = \Large\frac{count(w_{k-n+1},w_{k-n+2},...,w_{k})}{count(w_{k-n+1},w_{k-n+2},...,w_{k-1})}$

Além disso, podemos inserir um pouco mais de aleatoriedade no modelo de duas formas:

  • Para n-gramas não existentes, um modelo de bigramas será usado para gerar a próxima palavra;
  • Se a palavra anterior não existir no vocabulário, a palavra predita será aleatória;
  • Em vez de selecionar a palavra mais comum gerada pelo experimento de Monte Carlo, uma palavra aleatória entre as top k palavras será selecionada.
In [22]:
class NGramModel:
    def __init__(self, n=3, top_k=10):
        self.n = n
        self.top_k = top_k

    def generate_n_grams_freq(self, n, tokens):
        n_grams = []

        for i in range(0, len(tokens) - n + 1):
            n_grams.append(tuple((tokens[k] for k in range(i, i+n))))

        return n_grams

    def __fit_bigram_model(self, tokens_freq, bigrams_freq):

        self.bigram_model = {}

        for bigram, bigram_freq in bigrams_freq.items():
            if not self.bigram_model.get(bigram[:1]):
                self.bigram_model[bigram[:1]] = {}
            self.bigram_model[bigram[:1]][bigram[1]] = bigram_freq/tokens_freq.get(bigram[0])

    def fit(self, tokens):

        tokens_freq = Counter(tokens)
        bigrams_freq = Counter(self.generate_n_grams_freq(2, tokens))
        self.__fit_bigram_model(tokens_freq, bigrams_freq)

        self.n_minus_1_grams_freq = Counter(self.generate_n_grams_freq(self.n - 1, tokens))
        self.n_grams_freq = Counter(self.generate_n_grams_freq(self.n, tokens))


        self.model = {}

        for n_gram, n_gram_freq in self.n_grams_freq.items():
            if not self.model.get(n_gram[:self.n-1]):
                self.model[n_gram[:self.n-1]] = {}
            self.model[n_gram[:self.n-1]][n_gram[-1]] = n_gram_freq/self.n_minus_1_grams_freq.get(n_gram[:self.n-1])

    def generate_next_word_from_bigram_model(self, current_word):
        if not self.bigram_model.get((current_word,)):
            current_word = random.choice(list(self.bigram_model.keys()))[0]

        categories = list(self.bigram_model[(current_word,)].keys())

        custom_dist = rv_discrete(name='custom', values=(np.arange(len(categories)),
                                                         list(self.bigram_model[(current_word,)].values())))

        # Amostragem da distribuição
        num_samples = 1000
        samples = custom_dist.rvs(size=num_samples)
        samples = [categories[i] for i in samples]

        samples_counter = Counter(samples)

        top_10_samples = samples_counter.most_common(self.top_k)

        next_word = random.choice(top_10_samples)[0]
        return next_word

    def generate_initial_prompt(self, prompt):
        output_size = len(prompt)
        output = " ".join(prompt)
        prompt = (prompt[-1],)

        while output_size < self.n - 1:
            next_word = self.generate_next_word_from_bigram_model(prompt[0])
            output = output + " " + next_word
            output_size += 1
            prompt = (output.split()[-1], )
        return output

    def generate_text(self, prompt, output_size=10):
        prompt = clean_text(prompt)
        output = " ".join(prompt)
        prompt = tuple(prompt)

        if len(prompt) == 0:
            print("prompt cannot be empty!")
            return None

        if len(prompt) < self.n - 1:
            prompt = self.generate_initial_prompt(prompt)
            output = prompt
            prompt = tuple(prompt.split())

        while output_size > 0:
            if not self.model.get(prompt):
                next_word = self.generate_next_word_from_bigram_model(prompt[-1])
            else:
                categories = list(self.model[prompt].keys())

                custom_dist = rv_discrete(name='custom', values=(np.arange(len(categories)), list(self.model[prompt].values())))

                # Amostragem da distribuição
                num_samples = 1000
                samples = custom_dist.rvs(size=num_samples)
                samples = [categories[i] for i in samples]

                samples_counter = Counter(samples)
                top_k_samples = samples_counter.most_common(self.top_k)

                next_word = random.choice(top_k_samples)[0]

            output = output + " " + next_word
            output_size -= 1
            prompt = tuple(output.split()[-(self.n-1):])

        return output
In [23]:
ngram_model = NGramModel(n=5)
ngram_model.fit(tokens)
In [24]:
ngram_model.generate_text("the computer", 30)
Out[24]:
'the computer he is also to see him to steer him away from a life of crime as her dad is being held hostage in the back seat that strikes at him the car'

Referências¶

  • STAT 504: Analysis of Discrete Data. PennState. Disponível em: https://online.stat.psu.edu/stat504/
  • CS 447 - Natural Language Processing. University of Illinois Urbana-Champaign. Disponível em: https://siebelschool.illinois.edu/academics/courses/cs447